Web Components与现代前端组件化架构
在前端开发中,我们经常使用 React、Vue、Angular 等框架来构建组件化应用。但你是否想过,有没有一种方式可以不依赖任何框架,就能创建可复用的组件?Web Components 就是答案。
Web Components 是一套浏览器原生支持的组件化标准,它让我们可以创建真正的"一次编写,到处运行"的组件。无论你使用什么框架,甚至不使用框架,都可以使用这些组件。
一、Web Components 的四大核心技术
🎯 核心理念:Web Components 让组件化回归浏览器原生,不依赖任何框架。
1. Custom Elements(自定义元素)
Custom Elements 让我们可以创建自己的 HTML 标签。
// 创建一个简单的自定义元素
class MyButton extends HTMLElement {
constructor() {
super()
this.innerHTML = '<button>点击我</button>'
}
}
// 注册自定义元素
customElements.define('my-button', MyButton)
<!-- 在 HTML 中使用 -->
<my-button></my-button>
实际效果:页面上会显示一个按钮,就像使用普通的 HTML 标签一样。
2. Shadow DOM(影子 DOM)
Shadow DOM 提供了真正的样式和结构封装。
class MyCard extends HTMLElement {
constructor() {
super()
// 创建 Shadow DOM
const shadow = this.attachShadow({ mode: 'open' })
// 创建组件内容
shadow.innerHTML = `
<style>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.title {
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
}
</style>
<div class="card">
<div class="title">卡片标题</div>
<div class="content">卡片内容</div>
</div>
`
}
}
customElements.define('my-card', MyCard)
关键优势:样式完全隔离,不会影响页面其他元素,也不会被外部样式影响。
3. HTML Templates(HTML 模板)
HTML Templates 让我们可以定义可复用的 HTML 结构。
<!-- 定义模板 -->
<template id="user-card-template">
<style>
.user-card {
display: flex;
align-items: center;
padding: 12px;
border: 1px solid #eee;
border-radius: 6px;
margin: 8px 0;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 12px;
}
.info {
flex: 1;
}
.name {
font-weight: bold;
margin-bottom: 4px;
}
.email {
color: #666;
font-size: 14px;
}
</style>
<div class="user-card">
<img class="avatar" src="" alt="头像" />
<div class="info">
<div class="name"></div>
<div class="email"></div>
</div>
</div>
</template>
// 使用模板创建组件
class UserCard extends HTMLElement {
constructor() {
super()
const template = document.getElementById('user-card-template')
const templateContent = template.content.cloneNode(true)
this.attachShadow({ mode: 'open' }).appendChild(templateContent)
}
// 设置用户数据
setUser(user) {
const shadow = this.shadowRoot
shadow.querySelector('.avatar').src = user.avatar
shadow.querySelector('.avatar').alt = user.name
shadow.querySelector('.name').textContent = user.name
shadow.querySelector('.email').textContent = user.email
}
}
customElements.define('user-card', UserCard)
4. ES Modules(ES 模块)
ES Modules 让我们可以模块化地组织 Web Components。
// components/Button.js
export class MyButton extends HTMLElement {
constructor() {
super()
this.render()
}
render() {
this.innerHTML = `
<button class="my-button">
<slot></slot>
</button>
`
}
}
customElements.define('my-button', MyButton)
// components/Card.js
export class MyCard extends HTMLElement {
constructor() {
super()
this.render()
}
render() {
this.innerHTML = `
<div class="card">
<div class="card-header">
<slot name="header"></slot>
</div>
<div class="card-body">
<slot name="body"></slot>
</div>
</div>
`
}
}
customElements.define('my-card', MyCard)
<!-- 在 HTML 中引入 -->
<script type="module" src="./components/Button.js"></script>
<script type="module" src="./components/Card.js"></script>
<!-- 使用组件 -->
<my-card>
<div slot="header">卡片标题</div>
<div slot="body">卡片内容</div>
</my-card>
二、生命周期:组件的生老病死
Web Components 提供了完整的生命周期钩子,让我们可以在组件的不同阶段执行相应的逻辑。
class LifecycleDemo extends HTMLElement {
constructor() {
super()
console.log('1. 构造函数执行')
}
// 组件被添加到 DOM 时调用
connectedCallback() {
console.log('2. 组件已连接到 DOM')
this.render()
}
// 组件从 DOM 中移除时调用
disconnectedCallback() {
console.log('3. 组件已从 DOM 中移除')
this.cleanup()
}
// 组件属性变化时调用
attributeChangedCallback(name, oldValue, newValue) {
console.log(`4. 属性 ${name} 从 ${oldValue} 变为 ${newValue}`)
this.updateContent()
}
// 定义需要监听的属性
static get observedAttributes() {
return ['title', 'count']
}
render() {
this.innerHTML = `
<div class="lifecycle-demo">
<h3>${this.getAttribute('title') || '默认标题'}</h3>
<p>计数: ${this.getAttribute('count') || '0'}</p>
</div>
`
}
updateContent() {
// 根据属性变化更新内容
const title = this.getAttribute('title') || '默认标题'
const count = this.getAttribute('count') || '0'
this.querySelector('h3').textContent = title
this.querySelector('p').textContent = `计数: ${count}`
}
cleanup() {
// 清理工作,如移除事件监听器
console.log('执行清理工作')
}
}
customElements.define('lifecycle-demo', LifecycleDemo)
<!-- 使用示例 -->
<lifecycle-demo title="我的组件" count="5"></lifecycle-demo>
<script>
// 动态修改属性
setTimeout(() => {
const demo = document.querySelector('lifecycle-demo')
demo.setAttribute('count', '10')
}, 2000)
</script>
三、属性与事件:组件的对外接口
属性系统
class ProductCard extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
}
// 定义可观察的属性
static get observedAttributes() {
return ['name', 'price', 'image', 'description']
}
// 属性变化时的处理
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render()
}
}
// 获取属性值
get name() {
return this.getAttribute('name') || ''
}
set name(value) {
this.setAttribute('name', value)
}
get price() {
return this.getAttribute('price') || '0'
}
set price(value) {
this.setAttribute('price', value)
}
render() {
this.shadowRoot.innerHTML = `
<style>
.product-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
max-width: 300px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.product-image {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 4px;
}
.product-name {
font-size: 18px;
font-weight: bold;
margin: 12px 0 8px 0;
}
.product-price {
color: #e74c3c;
font-size: 20px;
font-weight: bold;
}
.product-description {
color: #666;
margin-top: 8px;
line-height: 1.4;
}
.add-to-cart {
background: #3498db;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
margin-top: 12px;
width: 100%;
}
.add-to-cart:hover {
background: #2980b9;
}
</style>
<div class="product-card">
<img class="product-image" src="${this.getAttribute('image') || ''}" alt="${this.name}">
<div class="product-name">${this.name}</div>
<div class="product-price">¥${this.price}</div>
<div class="product-description">${this.getAttribute('description') || ''}</div>
<button class="add-to-cart">加入购物车</button>
</div>
`
// 添加事件监听
this.addEventListeners()
}
addEventListeners() {
const button = this.shadowRoot.querySelector('.add-to-cart')
button.addEventListener('click', () => {
this.dispatchEvent(
new CustomEvent('add-to-cart', {
detail: {
name: this.name,
price: this.price,
},
bubbles: true,
})
)
})
}
}
customElements.define('product-card', ProductCard)
<!-- 使用组件 -->
<product-card
name="iPhone 15"
price="5999"
image="https://example.com/iphone15.jpg"
description="最新款 iPhone,性能强劲"
>
</product-card>
<script>
// 监听组件事件
document.addEventListener('add-to-cart', (event) => {
console.log('添加到购物车:', event.detail)
// 处理添加到购物车的逻辑
})
</script>
四、插槽系统:灵活的内容分发
插槽(Slots)让我们可以在组件中插入自定义内容。
class MyModal extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
}
connectedCallback() {
this.render()
}
render() {
this.shadowRoot.innerHTML = `
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
padding: 24px;
max-width: 500px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #eee;
}
.modal-title {
font-size: 20px;
font-weight: bold;
margin: 0;
}
.close-button {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
}
.close-button:hover {
color: #333;
}
.modal-body {
margin-bottom: 16px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-secondary {
background: #95a5a6;
color: white;
}
</style>
<div class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">
<slot name="title">默认标题</slot>
</h3>
<button class="close-button">×</button>
</div>
<div class="modal-body">
<slot name="body">默认内容</slot>
</div>
<div class="modal-footer">
<slot name="footer">
<button class="btn btn-secondary">取消</button>
<button class="btn btn-primary">确定</button>
</slot>
</div>
</div>
</div>
`
this.addEventListeners()
}
addEventListeners() {
const closeButton = this.shadowRoot.querySelector('.close-button')
const overlay = this.shadowRoot.querySelector('.modal-overlay')
closeButton.addEventListener('click', () => this.close())
overlay.addEventListener('click', (e) => {
if (e.target === overlay) this.close()
})
}
close() {
this.dispatchEvent(new CustomEvent('modal-close', { bubbles: true }))
this.remove()
}
}
customElements.define('my-modal', MyModal)
<!-- 使用插槽 -->
<my-modal>
<span slot="title">确认删除</span>
<div slot="body">
<p>您确定要删除这个项目吗?此操作不可撤销。</p>
</div>
<div slot="footer">
<button class="btn btn-secondary">取消</button>
<button class="btn btn-primary" style="background: #e74c3c;">删除</button>
</div>
</my-modal>
五、与框架集成:最佳实践
在 React 中使用 Web Components
// Web Component
class MyCounter extends HTMLElement {
constructor() {
super()
this.count = 0
this.attachShadow({ mode: 'open' })
this.render()
}
render() {
this.shadowRoot.innerHTML = `
<style>
.counter {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid #ddd;
border-radius: 8px;
}
.count {
font-size: 24px;
font-weight: bold;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
background: #3498db;
color: white;
}
.btn:hover {
background: #2980b9;
}
</style>
<div class="counter">
<button class="btn" id="decrement">-</button>
<span class="count">${this.count}</span>
<button class="btn" id="increment">+</button>
</div>
`
this.addEventListeners()
}
addEventListeners() {
this.shadowRoot.getElementById('increment').addEventListener('click', () => {
this.count++
this.render()
this.dispatchEvent(
new CustomEvent('count-change', {
detail: { count: this.count },
})
)
})
this.shadowRoot.getElementById('decrement').addEventListener('click', () => {
this.count--
this.render()
this.dispatchEvent(
new CustomEvent('count-change', {
detail: { count: this.count },
})
)
})
}
}
customElements.define('my-counter', MyCounter)
// React 组件中使用
import React, { useRef, useEffect } from 'react'
function App() {
const counterRef = useRef(null)
useEffect(() => {
const counter = counterRef.current
const handleCountChange = (event) => {
console.log('计数变化:', event.detail.count)
}
counter.addEventListener('count-change', handleCountChange)
return () => {
counter.removeEventListener('count-change', handleCountChange)
}
}, [])
return (
<div>
<h1>React + Web Components 示例</h1>
<my-counter ref={counterRef}></my-counter>
</div>
)
}
在 Vue 中使用 Web Components
<template>
<div>
<h1>Vue + Web Components 示例</h1>
<my-counter @count-change="handleCountChange"></my-counter>
<p>当前计数: {{ currentCount }}</p>
</div>
</template>
<script>
export default {
data() {
return {
currentCount: 0,
}
},
methods: {
handleCountChange(event) {
this.currentCount = event.detail.count
},
},
}
</script>
六、性能优化:让组件跑得更快
懒加载组件
// 懒加载 Web Component
class LazyComponent extends HTMLElement {
constructor() {
super()
this.loaded = false
}
connectedCallback() {
if (!this.loaded) {
this.loadComponent()
}
}
async loadComponent() {
// 显示加载状态
this.innerHTML = '<div>加载中...</div>'
try {
// 动态导入组件
const { MyHeavyComponent } = await import('./HeavyComponent.js')
// 创建组件实例
const component = new MyHeavyComponent()
this.appendChild(component)
this.loaded = true
} catch (error) {
this.innerHTML = '<div>加载失败</div>'
console.error('组件加载失败:', error)
}
}
}
customElements.define('lazy-component', LazyComponent)
组件缓存
// 组件缓存管理器
class ComponentCache {
constructor() {
this.cache = new Map()
}
get(key) {
return this.cache.get(key)
}
set(key, component) {
this.cache.set(key, component)
}
has(key) {
return this.cache.has(key)
}
}
const componentCache = new ComponentCache()
// 使用缓存的组件
class CachedComponent extends HTMLElement {
constructor() {
super()
this.componentKey = this.getAttribute('component-key')
}
connectedCallback() {
if (componentCache.has(this.componentKey)) {
// 使用缓存的组件
const cachedComponent = componentCache.get(this.componentKey)
this.appendChild(cachedComponent.cloneNode(true))
} else {
// 创建新组件并缓存
this.createComponent()
}
}
createComponent() {
// 创建组件的逻辑
const component = document.createElement('div')
component.textContent = '新组件'
componentCache.set(this.componentKey, component)
this.appendChild(component)
}
}
customElements.define('cached-component', CachedComponent)
七、实际项目中的应用场景
1. 设计系统组件库
// 按钮组件
class DesignButton extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
}
static get observedAttributes() {
return ['variant', 'size', 'disabled']
}
attributeChangedCallback() {
this.render()
}
render() {
const variant = this.getAttribute('variant') || 'primary'
const size = this.getAttribute('size') || 'medium'
const disabled = this.hasAttribute('disabled')
this.shadowRoot.innerHTML = `
<style>
.btn {
border: none;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
transition: all 0.2s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 变体样式 */
.btn--primary {
background: #3498db;
color: white;
}
.btn--primary:hover:not(:disabled) {
background: #2980b9;
}
.btn--secondary {
background: #95a5a6;
color: white;
}
.btn--secondary:hover:not(:disabled) {
background: #7f8c8d;
}
/* 尺寸样式 */
.btn--small {
padding: 6px 12px;
font-size: 12px;
}
.btn--medium {
padding: 8px 16px;
font-size: 14px;
}
.btn--large {
padding: 12px 24px;
font-size: 16px;
}
</style>
<button class="btn btn--${variant} btn--${size}" ${disabled ? 'disabled' : ''}>
<slot></slot>
</button>
`
}
}
customElements.define('design-button', DesignButton)
2. 第三方集成组件
// 地图组件
class MapComponent extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
this.map = null
}
static get observedAttributes() {
return ['latitude', 'longitude', 'zoom']
}
attributeChangedCallback() {
if (this.map) {
this.updateMap()
}
}
connectedCallback() {
this.render()
this.initMap()
}
render() {
this.shadowRoot.innerHTML = `
<style>
.map-container {
width: 100%;
height: 400px;
border: 1px solid #ddd;
border-radius: 8px;
}
</style>
<div class="map-container" id="map"></div>
`
}
async initMap() {
// 动态加载地图 API
if (!window.google) {
await this.loadGoogleMapsAPI()
}
const mapContainer = this.shadowRoot.getElementById('map')
const lat = parseFloat(this.getAttribute('latitude')) || 39.9042
const lng = parseFloat(this.getAttribute('longitude')) || 116.4074
const zoom = parseInt(this.getAttribute('zoom')) || 10
this.map = new google.maps.Map(mapContainer, {
center: { lat, lng },
zoom: zoom,
})
}
loadGoogleMapsAPI() {
return new Promise((resolve) => {
const script = document.createElement('script')
script.src = 'https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY'
script.onload = resolve
document.head.appendChild(script)
})
}
updateMap() {
if (this.map) {
const lat = parseFloat(this.getAttribute('latitude'))
const lng = parseFloat(this.getAttribute('longitude'))
const zoom = parseInt(this.getAttribute('zoom'))
this.map.setCenter({ lat, lng })
this.map.setZoom(zoom)
}
}
}
customElements.define('map-component', MapComponent)
八、常见问题与解决方案
问题1:样式不生效
// 错误做法:样式在 Shadow DOM 外部定义
class BadComponent extends HTMLElement {
constructor() {
super()
this.innerHTML = '<div class="my-component">内容</div>'
}
}
// 正确做法:样式在 Shadow DOM 内部定义
class GoodComponent extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
this.shadowRoot.innerHTML = `
<style>
.my-component {
color: red;
}
</style>
<div class="my-component">内容</div>
`
}
}
问题2:事件监听器内存泄漏
class EventComponent extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
this.boundHandler = this.handleClick.bind(this)
}
connectedCallback() {
this.shadowRoot.innerHTML = '<button>点击我</button>'
this.shadowRoot.querySelector('button').addEventListener('click', this.boundHandler)
}
disconnectedCallback() {
// 清理事件监听器
this.shadowRoot.querySelector('button').removeEventListener('click', this.boundHandler)
}
handleClick() {
console.log('按钮被点击')
}
}
问题3:属性变化不响应
class ReactiveComponent extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
}
// 必须定义 observedAttributes
static get observedAttributes() {
return ['title', 'count']
}
// 属性变化时会被调用
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render()
}
}
render() {
this.shadowRoot.innerHTML = `
<div>
<h3>${this.getAttribute('title') || '默认标题'}</h3>
<p>计数: ${this.getAttribute('count') || '0'}</p>
</div>
`
}
}
结语
Web Components 为我们提供了一种不依赖框架的组件化解决方案。它特别适合:
- 跨框架复用:一次编写,在 React、Vue、Angular 中都能使用
- 渐进式采用:可以在现有项目中逐步引入
- 性能优化:原生浏览器支持,性能优秀
- 长期维护:不依赖第三方框架,技术栈稳定
但也要注意 Web Components 的局限性:
- 浏览器兼容性(需要 polyfill)
- 开发体验不如框架友好
- 生态系统相对较小
选择 Web Components 还是框架组件,要根据具体项目需求来决定。记住:技术服务于业务,选择最适合的解决方案。